#
# peerscape.db.invitation
#
# Copyright 2008-2009 Helsinki Institute for Information Technology
# and the authors.
#
# Authors: Ken Rimey <rimey@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

"""
Friendship/membership invitations
"""

from __future__ import with_statement

import hmac
from hashlib import sha1
from binascii import hexlify, unhexlify
from os import urandom
from copy import copy
import logging

from peerscape.discovery import lookup
from peerscape.lib.cipher import Cipher
from peerscape.lib.sexp import encode, decode
from peerscape.db.util import parse_json_object
from peerscape.core.identity import generate, decode_identity
from peerscape.core.item import decode_item, create_root_item
from peerscape.core.policy import Unauthorized
from peerscape.core.iid import hex

def _test_friendship_invitation():
    """
    >>> from peerscape import DB, sync

    >>> db = DB()
    >>> p = db.init_persona('Person 1')
    >>> code = db.create_friendship_invitation_code(p)
    >>> code is not None
    True

    >>> db2 = DB()
    >>> p2 = db2.init_persona('Person 2')
    >>> iid = db2.fetch_invitation_and_make_pending(code)
    >>> iid == p
    True

    >>> data = db2.get_pending_invitation(p)
    >>> i = data['invitation']
    >>> i.iid == p
    True

    >>> data['type']
    'friendship_invitation'
    >>> data['iid'] == p
    True
    >>> data['code'] == code
    True

    >>> sync(db, db2, p)
    >>> i.accept(db2)
    'OK'

    >>> db2.list_friends(p) == [p2]
    True
    >>> db2.list_friends(p2) == [p]
    True

    >>> sync(db2, db)
    >>> db.list_friends(p) == [p2]
    True
    >>> db.list_friends(p2) == [p]
    True

    >>> db.get_group_secret(p) == db2.get_group_secret(p)
    True
    >>> db.get_group_secret(p2) == db2.get_group_secret(p2)
    True

    >>> event = db.get_last_event(p)
    >>> event['comment']
    u'Added Person 2 to friends.'
    >>> event['iid'] == p2
    True

    >>> event = db.get_last_event(p2)
    >>> event['comment']
    u'Added Person 1 to friends.'
    >>> event['iid'] == p
    True

    >>> db.fetch_invitation(code) is None
    True

    >>> db.close()
    >>> db2.close()
    """
    pass

def _test_membership_invitation():
    """
    >>> from peerscape import DB, sync

    >>> db = DB()
    >>> p = db.init_persona('Person 1')
    >>> g = db.create_group('Test Group')
    >>> code = db.create_membership_invitation_code(g)
    >>> code is not None
    True

    >>> db2 = DB()
    >>> p2 = db2.init_persona('Person 2')
    >>> iid = db2.fetch_invitation_and_make_pending(code)
    >>> iid == g
    True

    >>> data = db2.get_pending_invitation(g)
    >>> i = data['invitation']
    >>> i.iid == g
    True

    >>> data['type']
    'membership_invitation'
    >>> data['iid'] == g
    True
    >>> data['code'] == code
    True

    >>> sync(db, db2, g)
    >>> i.accept(db2)
    'OK'
    >>> db2.list_members(g) == [p, p2]
    True

    >>> sync(db2, db)
    >>> db.list_members(g) == [p, p2]
    True

    >>> db.get_group_secret(g) == db2.get_group_secret(g)
    True

    >>> event = db.get_last_event(g)
    >>> event['comment']
    u'Person 2 joined the group.'
    >>> event['iid'] == p2
    True

    >>> db.fetch_invitation(code) is None
    True

    >>> db.close()
    >>> db2.close()
    """
    pass

def _test_in_band_invitations():
    """
    >>> from peerscape import DB

    >>> db = DB()
    >>> p1, s1 = db.create_principal('User 1')
    >>> p2, s2 = db.create_principal('User 2')

    >>> db.use_persona(p1, s1)
    >>> db.is_invitation_pending(p2)
    False
    >>> db.invite_friend(p2)
    >>> db.is_invitation_pending(p2)
    True

    >>> db.use_persona(p2, s2)
    >>> data = db.get_pending_invitation(p1)
    >>> len(db.list_pending_invitations())
    1

    >>> data['type']
    'friendship_invitation'
    >>> data['iid'] == p1
    True

    >>> i = data['invitation']
    >>> i.accept(db)
    'OK'

    >>> db.clear_pending_invitation(p1)
    >>> db.get_pending_invitation(p1) is None
    True
    >>> len(db.list_pending_invitations())
    0

    >>> db.close()
    """
    pass

class InvitationDBMixin(object):

    #
    # Out-of-band invitations
    #

    def create_friendship_invitation_code(self, inviter=None, **kwargs):
        """
        Return a new friendship invitation code.

        Specifying a third party as the inviter is not typical.
        """
        inviter = inviter or self.get_my_iid()
        obj = create_friendship_invitation(self, inviter, **kwargs)
        return stash(obj.encode())

    def create_membership_invitation_code(self, group, **kwargs):
        """
        Return a new membership invitation code.
        """
        obj = create_membership_invitation(self, group, **kwargs)
        return stash(obj.encode())

    def fetch_invitation_and_make_pending(self, code):
        """
        Retrieve an invitation by code and make it pending.

        The IID of the inviting person or group is returned for reference.
        LookupError is raised if the invitation is not found.
        """
        code = str(code)        # in case it's unicode
        obj = self.fetch_invitation(code)
        if not obj:
            raise LookupError('Invalid invitation code.')

        inviter = obj.iid
        invitee = self.get_my_iid()
        data = dict(invitation=obj.encode(), code=code)
        self.leave_message(invitee, data, inviter)
        return inviter

    def fetch_invitation(self, code):
        """
        Destructively retrieve an invitation object.
        """
        s = fetch(code)
        return s and decode_invitation(s)

    #
    # In-band invitations
    #

    def invite_friend(self, invitee, **kwargs):
        """
        Put a friendship invitation in the profile of the invitee.
        """
        inviter = self.get_my_iid()
        obj = create_friendship_invitation(self, inviter, **kwargs)
        data = dict(invitation=obj.encode())
        self.leave_message(invitee, data, inviter)

    def is_invitation_pending(self, invitee, inviter=None):
        """
        Test whether the pending invitation exists.

        This is primarily intended for use by the inviter in
        conjunction with invite_friend(), but anyone can call it.
        """
        return self.is_message_pending(invitee, inviter)

    #
    # Pending invitations
    #

    def get_pending_invitation(self, inviter):
        """
        Return a dictionary with information about a pending invitation.

        The inviter is the IID of a person or group.

        Dictionary fields include the following:
         * invitation - the invitation instance.
         * type - "friendship_invitation" or "membership_invitation".
         * iid - the inviting person or group.  This is the same as
           the value of the "inviter" parameter.
         * code - the invitation code.  Normally this is only present
           for out-of-band invitations.

        If there is no invitation pending from the specified inviter,
        the return value is None.
        """
        try:
            data = self.read_message(inviter)
            if 'invitation' in data:
                obj = decode_invitation(data['invitation'])
            else:
                return None
        except LookupError:
            return None
        except ValueError:
            logging.exception('Error in pending invitation (%s):', inviter)
            return None

        if obj.iid != inviter:
            logging.warn('Ignoring third-party invitation.')
            return None

        data['invitation'] = obj
        data['type'] = obj.type
        data['iid'] = obj.iid
        return data

    def list_pending_invitations(self):
        """
        Return a list of dictionaries with information about pending invitations.

        The results are in log order from oldest to newest.
        """
        results = (self.get_pending_invitation(inviter)
                   for inviter in self.list_messages())
        return [data for data in results if data]

    def clear_pending_invitation(self, inviter, invitee=None):
        """
        Delete any pending invitation from the specified person or group.

        This is primarily intended for use by the invitee, but it can
        also be used by the inviter to rescind a friendship invitation.
        """
        self.clear_message(inviter, invitee)

    #
    # The following are useful for testing without OpenLookup.
    #

    def _create_friendship_invitation(self, person=None, **kwargs):
        person = person or self.get_my_iid()
        return create_friendship_invitation(self, person, **kwargs)

    def _create_membership_invitation(self, group, **kwargs):
        return create_membership_invitation(self, group, **kwargs)

def stash(plaintext, ttl=7*24*3600):
    code = generate_code()

    key = strengthen(code)
    encryption_key = hmac.new(key, 'encryption key', sha1).digest()
    lookup_secret = hmac.new(key, 'opendht secret', sha1).digest()
    lookup_key = hmac.new(key, 'opendht key', sha1).digest()

    cipher = Cipher(encryption_key, IV=lookup_key)
    ciphertext = cipher.encrypt(plaintext)
    if len(ciphertext) > 1024:
        raise ValueError('Sorry, %d-byte invitation is too big for OpenLookup.'
                         % len(ciphertext))

    try:
        lookup.put(lookup_key, ciphertext, ttl=ttl, secret=lookup_secret)
        return code
    except lookup.Error, e:
        logging.warn('Stashing invitation: %s' % e)
        return None

def fetch(code):
    code = str(code).strip().lower()

    key = strengthen(code)
    encryption_key = hmac.new(key, 'encryption key', sha1).digest()
    lookup_secret = hmac.new(key, 'opendht secret', sha1).digest()
    lookup_key = hmac.new(key, 'opendht key', sha1).digest()

    cipher = Cipher(encryption_key, IV=lookup_key)
    for ciphertext in lookup.get(lookup_key):
        lookup.rm(lookup_key, ciphertext, secret=lookup_secret)
        return cipher.decrypt(ciphertext)
    else:
        return None

def generate_code(size=8):
    # Alphabet from CryptoID:
    alphabet = "abcdefghijkmnopqrstuvwxyz3456789" #a-z0-9 except 'l',0,1,2
    assert len(alphabet) == 32
    s = ''.join(alphabet[ord(byte) % 32] for byte in urandom(size))

    pieces = []
    n = -(-size // 5)
    for k in range(n):
        i = int(k * size / float(n) + 0.5)
        j = int((k + 1) * size / float(n) + 0.5)
        pieces.append(s[i:j])
    return '-'.join(pieces)

def strengthen(code, count=100000, salt='-peerscape-invitation'):
    key = str(code) + salt
    for i in range(count):
        key = sha1(key).digest()
    return key

class Invitation(object):
    def __init__(self, private_key, auth_item, secret):
        self.private_key = _decode_identity(private_key)
        self.item = _decode_item(auth_item, self.private_key)
        self.secret = secret

        self.iid = hexlify(self.item.iid)
        self.nominee = hexlify(create_root_item(self.private_key).iid)
        assert self.item.key == ['auth', self.nominee]

    def accept(self, db):
        try:
            return self._accept(db)
        except Unauthorized:
            return 'Unable to accept invitation.'

    def encode(self):
        return encode(self.export())

    def _accept(self, db):
        with db.write_transaction():
            my_iid = db.get_my_iid()
            my_private_key = db.get_private_key()

            info = parse_json_object(self.item.value)
            info['secret'] = db.encrypt_group_secret(self.secret, self.iid)
            info['name'] = db.get_text(my_iid, '/info/title.txt')

            db.put_item(self.item)
            db.put(self.iid, ['auth', my_iid], info,
                   identity=self.private_key, identity2=my_private_key)
            db.delete(self.iid, ['auth', self.nominee],
                      identity=my_private_key, identity2=self.private_key)
            return 'OK'

class MembershipInvitation(Invitation):
    type = 'membership_invitation'

    def export(self):
        return [self.type,
                ['private_key', self.private_key.encode_private()],
                ['auth_item', _encode_item(self.item)],
                ['secret', self.secret]]

    def _accept(self, db):
        with db.write_transaction():
            my_iid = db.get_my_iid()

            if my_iid in db.list_members(self.iid):
                # XXX Support merging of roles?
                return 'Redundant invitation: already a member.'

            status = Invitation._accept(self, db)
            if status != 'OK':
                return status

            name = db.get_text(my_iid, '/info/title.txt')
            if name:
                comment = '%s joined the group.' % name
                db.post_event(self.iid, comment, my_iid)

            return 'OK'

class FriendshipInvitation(Invitation):
    type = 'friendship_invitation'

    def __init__(self, private_key, auth_item, secret, salt, srs):
        Invitation.__init__(self, private_key, auth_item, secret)
        self.salt = salt        # Key for regenerating SRS
        self.srs = srs          # SRS = Secret Return Secret

    def export(self):
        return [self.type,
                ['private_key', self.private_key.encode_private()],
                ['auth_item', _encode_item(self.item)],
                ['secret', self.secret],
                ['salt', self.salt],
                ['srs', self.srs]]

    def _accept(self, db):
        with db.write_transaction():
            my_iid = db.get_my_iid()
            your_iid = self.iid

            if my_iid == your_iid:
                return 'Cannot accept a friendship invitation from yourself.'

            if my_iid in db.list_members(your_iid):
                return 'Redundant invitation: already a friend.'

            my_name = db.get_text(my_iid, '/info/title.txt')
            your_name = db.get_text(your_iid, '/info/title.txt')

            status = Invitation._accept(self, db)
            if status != 'OK':
                return status

            cipher = Cipher(self.srs)
            my_secret = db.get_group_secret(my_iid)
            encrypted_secret = hexlify(cipher.encrypt(my_secret))

            db.update_json(my_iid, ['auth', your_iid],
                           salt=self.salt,
                           secret=encrypted_secret,
                           name=your_name)

            if my_name:
                comment = 'Added %s to friends.' % my_name
                db.post_event(your_iid, comment, my_iid)

            if your_name:
                comment = 'Added %s to friends.' % your_name
                db.post_event(my_iid, comment, your_iid)

            return 'OK'

def create_membership_invitation(db, group, **kwargs):
    group = hex(group)
    identity = generate()
    with db.read_transaction():
        assert db.get_dataset_type(group) == 'group'
        secret = db.get_group_secret(group)

    item = _create_auth_item(db, group, identity, kwargs)
    return MembershipInvitation(identity, item, secret)

def create_friendship_invitation(db, person, **kwargs):
    person = hex(person)
    identity = generate()
    salt = hexlify(urandom(20))
    with db.read_transaction():
        assert db.get_dataset_type(person) == 'principal'
        secret = db.get_group_secret(person)
        srs = db.get_secret(salt, 'encrypt group secret')

    item = _create_auth_item(db, person, identity, kwargs)
    return FriendshipInvitation(identity, item, secret, salt, srs)

def decode_invitation(invitation):
    data = decode(invitation)
    type = data.pop(0)
    fields = dict(data)
    if type == 'friendship_invitation':
        return FriendshipInvitation(**fields)
    elif type == 'membership_invitation':
        return MembershipInvitation(**fields)
    else:
        raise ValueError

class Okay(Exception):
    pass

def _create_auth_item(db, iid, identity, info):
    nominee = hexlify(create_root_item(identity).iid)
    try:
        with db.write_transaction():
            key = ['auth', nominee]
            db.put(iid, key, info, identity2=identity)
            item = db.get_item(iid, key)
            raise Okay
    except Okay:
        return item

def _decode_identity(identity):
    return decode_identity(identity) if isinstance(identity, str) else identity

def _decode_item(item, identity):
    item = decode_item(item) if isinstance(item, str) else item
    item.countersign(identity)
    return item

def _encode_item(item):
    # Remove countersignature to save space, since it can easily be
    # reconstructed by the recipient.
    item = copy(item)
    item.sig2 = None
    item.pk2 = None
    return item.encode()

if __name__ == '__main__':
    import doctest
    doctest.testmod()
